fix: normalize data store attributes to plain strings in JS bridge#429
fix: normalize data store attributes to plain strings in JS bridge#429
Conversation
abb6056 to
9c68308
Compare
The WordPress data store's `getEditedPostContent()` may return
`{raw, rendered}` objects instead of plain strings because it uses
`getEditedEntityRecord` (which preserves the object shape) rather than
`getRawEntityRecord` (which extracts `.raw`).
Add `normalizeAttribute()` to always extract the raw string before
returning values to the native host via `getTitleAndContent()`.
Also remove the redundant `getContent()` bridge method and its iOS
public API — `getTitleAndContent()` is the single accessor for editor
state.
9c68308 to
aeb7f66
Compare
- Make normalizeAttribute explicitly handle null/undefined instead of
relying on typeof null === 'object' with optional chaining
- Coerce non-string primitives to strings via String()
- Normalize postTitleRef/postContentRef initialization defensively
- Normalize block content in appendTextAtCursor
- Add unit tests for edge cases: null, undefined, {raw: null},
{raw: undefined}, arrays, non-string primitives
- Add unit tests for changed flag correctness with object values
- Add unit tests for appendTextAtCursor (object content, string
content, no block selected, unsupported block type)
- Add changed flag assertions to E2E object-injection regression tests
dcalhoun
left a comment
There was a problem hiding this comment.
Looks good! Left a few thoughts, but no blockers.
| } | ||
|
|
||
| /// Returns the current editor content. | ||
| public func getContent() async throws -> String { |
There was a problem hiding this comment.
It appears there is one location using this method that we need to update: the unused comment editor.
| vi.mock( '@wordpress/core-data', () => ( { | ||
| store: { name: 'core' }, | ||
| } ) ); | ||
| vi.mock( '@wordpress/editor', () => ( { | ||
| store: { name: 'core/editor' }, | ||
| } ) ); | ||
| vi.mock( '@wordpress/blocks', () => ( { | ||
| parse: vi.fn( () => [] ), | ||
| serialize: vi.fn( () => '' ), | ||
| getBlockType: vi.fn(), | ||
| } ) ); | ||
| vi.mock( '@wordpress/rich-text', () => ( { | ||
| create: vi.fn( ( { html } ) => ( { | ||
| text: html, | ||
| formats: [], | ||
| replacements: [], | ||
| start: 0, | ||
| end: html.length, | ||
| } ) ), | ||
| insert: vi.fn( ( value, text ) => ( { | ||
| text: value.text + text, | ||
| formats: [], | ||
| replacements: [], | ||
| start: 0, | ||
| end: value.text.length + text.length, | ||
| } ) ), | ||
| toHTMLString: vi.fn( ( { value } ) => value.text ), | ||
| } ) ); | ||
| vi.mock( '@wordpress/block-editor', () => ( { | ||
| store: { name: 'core/block-editor' }, | ||
| } ) ); |
There was a problem hiding this comment.
I suggest we simplify the mocks in this file by reusing the base mocks in __mock__. Some need to be expanded with sensible defaults.
You could copy the below diff and pbpaste | git apply.
diff --git a/__mocks__/@wordpress/block-editor.js b/__mocks__/@wordpress/block-editor.js
index 7f90f182..12d53b27 100644
--- a/__mocks__/@wordpress/block-editor.js
+++ b/__mocks__/@wordpress/block-editor.js
@@ -1 +1,3 @@
// Intentionally empty — prevents the real module from loading.
+
+export const store = { name: 'core/block-editor' };
diff --git a/__mocks__/@wordpress/core-data.js b/__mocks__/@wordpress/core-data.js
index 7f90f182..046ff29f 100644
--- a/__mocks__/@wordpress/core-data.js
+++ b/__mocks__/@wordpress/core-data.js
@@ -1 +1 @@
-// Intentionally empty — prevents the real module from loading.
+export const store = { name: 'core/data' };
diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx
index 14ea7819..1a3fea16 100644
--- a/src/components/editor/test/use-host-bridge.test.jsx
+++ b/src/components/editor/test/use-host-bridge.test.jsx
@@ -44,17 +44,9 @@ vi.mock( '@wordpress/data', () => ( {
selectionChange: mockSelectionChange,
} ),
} ) );
-vi.mock( '@wordpress/core-data', () => ( {
- store: { name: 'core' },
-} ) );
-vi.mock( '@wordpress/editor', () => ( {
- store: { name: 'core/editor' },
-} ) );
-vi.mock( '@wordpress/blocks', () => ( {
- parse: vi.fn( () => [] ),
- serialize: vi.fn( () => '' ),
- getBlockType: vi.fn(),
-} ) );
+vi.mock( '@wordpress/core-data' );
+vi.mock( '@wordpress/editor' );
+vi.mock( '@wordpress/blocks' );
vi.mock( '@wordpress/rich-text', () => ( {
create: vi.fn( ( { html } ) => ( {
text: html,
@@ -72,9 +64,7 @@ vi.mock( '@wordpress/rich-text', () => ( {
} ) ),
toHTMLString: vi.fn( ( { value } ) => value.text ),
} ) );
-vi.mock( '@wordpress/block-editor', () => ( {
- store: { name: 'core/block-editor' },
-} ) );
+vi.mock( '@wordpress/block-editor' );
const defaultPost = {
id: 1,
| const mockGetSelectionEnd = vi.fn(); | ||
| const mockUpdateBlock = vi.fn(); | ||
| const mockSelectionChange = vi.fn(); | ||
|
|
| if ( typeof value === 'object' ) { | ||
| return value.raw ?? ''; | ||
| } |
There was a problem hiding this comment.
These seem unlikely, but I'll point out....
typeof [] === 'object'; we could add an explicit check with Array.isArray() if desired.
{ raw: { raw: 'some string' } } would return an object; we could recursively call normalizeAttribute( value.raw ) if desired. But I suppose the could create an infinite loop in really odd circumstances.

Summary
Fixes a bug where
editor.getTitleAndContent()could return JavaScript objects instead of plain strings to the native host app, corrupting the content in the host app.Root Cause
WordPress's Gutenberg data store has an architectural inconsistency in how
getEditedPostAttributehandles values from the edits layer vs the base entity record.When a post is loaded, title and content are stored as
{ raw: "..." }objects in the entity record (this is the shapegetPost()inbridge.jsconstructs). Two different normalization paths exist:getEditedPostAttribute('title')→getCurrentPostAttribute→getCurrentPost→getRawEntityRecord(extracts.raw) → thengetPostRawValueis applied — returns a plain string. ✅editEntityRecord):getEditedPostAttribute('title')→edits[attributeName]— returns whatever was stored in edits, with no normalization. ❌The edits reducer stores values verbatim:
So if anything dispatches
editEntityRecord('postType', type, id, { title: { raw: '...', rendered: '...' } }), the{raw, rendered}object goes directly intoedits.title, andgetEditedPostAttribute('title')returns the object — bypassinggetPostRawValueentirely.Similarly,
getEditedPostContent()has a code path that returnsrecord.contentfromgetEditedEntityRecordwithout normalization — if the content in the edits layer is an object, it passes through as-is.When does this happen in practice?
{ from: editedRecord[key], to: edits[key] }. If at any point the edited record contained a{raw, rendered}object, undo replays it as an edit — re-introducing the object.editEntityRecordwith REST API-shaped data rather than plain strings.Confirmed via E2E
We confirmed this by injecting
{raw, rendered}objects viaeditEntityRecordin a Playwright test and then callinggetTitleAndContent(), verifying the bridge returns plain strings (i.e.,normalizeAttribute()catches the object before it reaches the host). The underlyinggetEditedPostAttribute('title')selector does return the object in this scenario — confirmed separately during reproduction testing.We could not reproduce the bug through normal user interactions (typing, undo/redo, cache invalidation, REST API re-fetches) — the standard Gutenberg code paths happen to always produce string edits. The bug requires an internal code path that dispatches object-shaped edits, which is a Gutenberg data store implementation detail outside our control.
The Android/iOS host apps are not the source
Both the Android (
GBKitGlobal.Post.title: String) and iOS host apps pass plain strings to GutenbergKit. The WordPress Android app (WordPress-Android) also decomposes{raw, rendered}from the REST API into plain strings via FluxCPostModelbefore reaching GutenbergKit. The issue is entirely within the JS data store layer.What We Explored
1. Making native types match the
{raw, rendered}shapeWe initially updated iOS and Android to parse
{raw, rendered}objects. This worked mechanically, butrenderedis useless client-side — it's only available from the server's initial REST API response and becomes stale after any edit. The server-side rendering pipeline cannot be reproduced in the client.2. Hoisting the
{raw, rendered}wrapper into native input typesWe considered having
EditorConfigurationaccept{raw, rendered}objects. But the data store replaces them with plain strings after edits, so output normalization would still be needed — making the input change pure overhead.3. Using
getEditedPostAttribute('content')instead ofgetEditedPostContent()For
'content'specifically,getEditedPostAttributedelegates togetEditedPostContent()— same code path, same bug.Fix
Add a
normalizeAttribute()function at the JS bridge boundary that extracts.rawfrom objects or passes strings through unchanged. Applied to both title and content ingetTitleAndContent(), and to block content inappendTextAtCursor(). Also applied defensively to the initialpostTitleRefandpostContentRefinitialization.Also removed the redundant
getContent()bridge method (and its iOS public API) —getTitleAndContent()is now the single accessor for reading editor state.Note: The removal of
getContent()fromEditorViewControlleris a breaking change for iOS consumers. UsegetTitleAndContent()instead.Changes
src/components/editor/use-host-bridge.js: AddnormalizeAttribute(), apply togetTitleAndContent()andappendTextAtCursor(), defensively normalize ref initialization, removegetContent()src/components/editor/test/use-host-bridge.test.jsx: Unit tests for normalization edge cases (null, undefined, objects, arrays, primitives),changedflag correctness, andappendTextAtCursorwith object-shaped and string block contente2e/get-title-and-content.spec.js: E2E tests covering plain string return in all states, including regression tests that inject{raw, rendered}objects viaeditEntityRecordand verifygetTitleAndContent()normalizes them with correctchangedflag behaviore2e/editor-page.js: AddgetTitleAndContent()helper to page objectios/.../EditorViewController.swift: RemovegetContent()public API (breaking change)ios/.../EditorViewControllerDelegate.swift: Update doc referenceTest Plan
{raw, rendered}objects into the data store and verifyingnormalizeAttribute()returns plain strings with correctchangedflag